项目架构解读与重构:Mastra 深度集成 server
第15章我们搭好了框架:agents/tools 的接口设计、Hono 网关、mastra.ai 接入。但当项目往前走,"能跑"和"结构对"之间的差距开始显现。这节先看清整个项目的架构组成,再动手重构——把散落的 Mastra 集成收进 server 体系,让架构配得上后面要写的东西。
先看清我们在哪:项目架构全景
写了一个多月的 ai-chat-ui,此刻仓库里有 8 个包。先别急着写代码,把地图摊开——每个包是什么角色、依赖往哪指、发布的和不发布的是什么:
┌─────────────── 发布的库(机制层,npm 上 anyone 可装)───────────────┐
│ core 纯 TS 零依赖:Message/StreamChunk/ChatAdapter/useChat │
│ vue 组件层:ChatWindow/MessageBubble/InputArea… │
│ markdown 渲染层:markdown-it+DOMPurify+Shiki+KaTeX │
│ agents 服务端模型适配:ModelRegistry + 多协议 Adapter │
│ └─ /mastra 子路径:MastraAdapter + createMastraModel │
│ server Hono 网关:/api/chat SSE + /api/models + 认证限流 │
│ └─ /mastra 子路径:createMastraGateway(本节新增) │
└────────────────────────────────────────────────────────────────────┘
┌─────────────── 私有包(实例层,不发布)────────────────────────────┐
│ dev-server 唯一服务(8787):mock 剧本 + mastra agents + Studio │
│ playground 演示前端:连 8787,承载所有 Demo │
│ docs VitePress 文档站 │
└────────────────────────────────────────────────────────────────────┘
text
分层原则一句话:机制进库,实例进服务。发布的包只装"怎么接"的机制(适配器、网关、注册表),具体的 agent 定义、工具实现、演示剧本都留在私有服务包里。这条线画清楚,后面所有重构决策都有了裁判。
数据流走读:一条消息的完整旅程
架构图是静态的,把一条消息的旅程走一遍图就活了:
- 用户在 PromptInput 组件输入并发送;
- useChat(core 层)接到消息,交给当前选中的 ChatAdapter;
- 适配器把请求发到 dev-server 的
/api/chat(SSE); - 网关按
model参数从 ModelRegistry 取出对应 adapter——mock 剧本直接回放;mastra agent 则经 agents 层调用模型 API,工具调用、流式分帧都在这条线上完成; - 响应的 StreamChunk 流回前端,Message 系组件渲染文本,代码块/公式/图表交给 markdown 包的渲染器。
值得注意的一点:重构前的 Mastra 真实模型服务(mastra-app)游离在这条链路之外,只被 playground 里一个专门的 mastra-adapter 直连——它和 server 体系几乎没有结构性关联。这条"旁路"正是下面要拔掉的。
第一步不是动架构:目录结构重构
架构升级前先做了一次纯代码组织的清理。两个现场问题,都能在当时的仓库里指出来:
- 测试代码混在源码里:
*.test.ts和实现文件同目录,读源码要在一堆测试里翻; - agent 逻辑没有分目录:早期快速接入的 agent/工具代码散在包根目录。
做法没有技巧,就是一句把现状描述清楚的自然语言指令(问题是什么 + 要求按功能模块重新规划目录)。AI 执行完还顺手把约定固化成了规则文件 .claude/rules/project-structure.md,三条要点:
- 测试统一放
packages/<pkg>/__tests__/,内部目录镜像 src——src/composables/useTheme.ts的测试就是__tests__/composables/useTheme.test.ts; - src 根只留入口、类型声明、包级 types 三类文件;同类职责文件 ≥2 个才成组归目录,禁止一文一目录的过度碎片化;
- 模块目录按功能命名(
composables/、routes/、agents/),不按技术分层重复拆分。
为什么值得在"动架构"之前专门停下来做这件事:一是给人读——后面每章都还要往仓库里加东西;二是给 AI 做——目录约定固化成 rules 之后,之后每一次"加一个 agent""加一个工具"的提示词都变短了,AI 不用每次猜"放哪儿、测试搁哪儿"。重构产出的不只是新目录,还有让后续所有开发变便宜的规则。
为什么重构:三个真实存在的问题
重构不是为了好看,是被具体问题逼的。重构前夜的代码有三个结构性毛病,每一个都能在仓库里指出现场:
问题一:Mastra 游离在 server 体系之外。 @toimc/server 是纯网关,对 Mastra 一无所知;Mastra 的 Agent 跑在一个独立的 mastra-app 包里(4111 端口,@mastra/hono 自建路由)。15章为了快速接入搭了这条线,但它意味着:组件库的网关体系和 Mastra 是两条并行服务,前端也得维护两套协议适配。
问题二:同一个 agent 定义了两遍。 chat-agent 和 get_time/get_weather 两个工具,在 mastra-app/src/mastra/ 一份、mock-server/src/mastra/ 又一份——连系统指令都已经各自演化出了不同措辞。重复定义不是风格问题,是漂移问题:改一处忘另一处,线上行为和服务描述对不上。
问题三:前端两条 adapter 线。 playground 里 sse-adapter(连 8787,StreamChunk 线协议)和 mastra-adapter(连 4111,前端转协议——把 Mastra 原生 chunk 在浏览器里翻译成 StreamChunk)并存,后端选择器要做三选项。第10章设计 ChatAdapter 抽象就是为了屏蔽后端差异,结果我们自己先造出了两个后端协议。
三个问题指向同一个病根:15章按"教学顺序"长的架构,该按"工程结构"收一次了。
重构方案:三个关键决策
方案不是拍脑袋,每个决策都有明确的取舍。
决策一:机制进库——@toimc/server 新增 /mastra 子路径。 Mastra 集成不再是某个服务的私货,而是网关的原生能力。为了不污染纯网关的定位,集成放在可选子路径里,@mastra/core 声明为 optional peer——不装 Mastra 的宿主,@toimc/server 主入口一个字节都不变大(这是验收项,后面实测)。
决策二:实例收敛、服务改名——mock-server → dev-server。 所有 agent/工具定义收敛到唯一的服务包;mock-server 这个名字在它承载了正式 Mastra 服务后已经名不副实,改名为 dev-server(开发演示服务:mock 剧本 + mastra agents 双模式)。
决策三:删除 mastra-app,Studio 保留。 这是最需要想清楚的一条。npx mastra dev / mastra studio / mastra build 这套 CLI 不关心包名和目录名,它只认一件事:包里有静态命名导出 export const mastra = new Mastra({...})(默认路径 src/mastra/index.ts)。把这个导出文件迁到 dev-server,Studio 照常工作。真正删掉的只是那条自建 4111 服务线,它的三个能力全部有覆盖:SSE 端点→网关 /api/chat;运行时切模型→/api/providers;Bearer 认证→网关 auth 选项。
这是个值得记住的工程经验:退役一个东西前,先分清"它承载的能力"和"它的实现形态"。能力有替代方案时,实现形态就可以大胆删。
Studio 为什么值得"深度集成"
这次集成把 Mastra 应用收进 server 体系,最直接的回报是 Mastra Studio——官方提供的 AI 应用控制面板。注册的 agents、workflows、tools、MCP servers 在面板里全量可见:可以逐个节点调试、观察每次请求的输入上下文、工具调用链、每个 agent 的回复。有了它,AI 的行为从"盲猜"变成"可观测"——16-02 改一句 instructions 当场看 trace、16-03 跑一个 workflow 逐步看数据传递,都是踩在这块基础设施上。
启动方式:dev-server 包内 pnpm studio(即 mastra dev -e .env,4111 端口),自动连上静态导出的 export const mastra 实例,无需额外配置。
动手:@toimc/server/mastra 子路径
先写机制。agent 定义是纯配置对象——不 import 任何 Mastra 类型,服务层组装起来毫无心智负担:
// packages/server/src/mastra/index.ts(发布为 @toimc/server/mastra)
export interface MastraAgentDefinition {
/** 网关模型 id(/api/models 列表项与前端 model 参数) */
id: string
name?: string
description?: string
/** 'provider/model' 串或 { id, url, apiKey? } 对象(OpenAI 兼容端点) */
model: string | Record<string, unknown>
instructions?: string
/** createTool 产物字典 */
tools?: Record<string, unknown>
/** Memory 实例工厂(惰性:注册时调用一次;不传则无记忆) */
memory?: () => unknown
resource?: string
}
export async function createMastraGateway(
options: MastraGatewayOptions,
): Promise<{ app: Hono; registry: ModelRegistry }> {
const { models, agents = [], ...gatewayOptions } = options
const registry = /* models 传入 registry 或数组,同 createChatGateway */
for (const def of agents) {
try {
const created = await createMastraModel({
id: def.id, name: def.name, model: def.model,
...(def.instructions ? { instructions: def.instructions } : {}),
...(def.tools ? { tools: def.tools } : {}),
...(def.memory ? { memory: def.memory() } : {}),
})
registry.registerAdapter(def.id, created.adapter, created.info)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`mastra agent「${def.id}」注册失败:${message}…`)
}
}
const app = createChatGateway({ ...gatewayOptions, models: registry })
return { app, registry }
}
typescript
两个实现细节值得停一下:
createMastraModel来自@toimc/agents/mastra(15章的产物),内部动态import('@mastra/core/agent')——所以整个 server 主入口依然零 Mastra 依赖,/mastra子路径单独成产物(dist/mastra.mjs+ 独立 d.ts),宿主按需引入。- 构建失败整体抛错、启动即死,错误信息里带 agent id。静默降级(少注册一个 agent 继续跑)看似友好,实际是把配置错误拖到用户对话 404 时才暴露。
测试遵循红灯先行:先写"agents 定义注册进网关、/api/models 可见"的用例,跑出模块不存在的红灯,再实现转绿。四个用例覆盖:注册可见 / 无 agents 纯网关 / 构建失败报 agent id / memory 工厂只调一次。
dev-server 重组:目录即架构
改名后重组的目录,每一层都对应一个职责:
packages/dev-server/
├── src/
│ ├── index.ts # 服务入口:读 env → createMastraGateway → 挂路由 → 8787
│ ├── env.ts # 统一 env 解析(MASTRA_* 一套)
│ ├── agents/
│ │ ├── index.ts # ★注册表:buildAgentDefinitions(env) → [chatAgentDefinition(env)]
│ │ ├── chat-agent.ts
│ │ └── model-config.ts # env → Mastra model 字段(串/对象两形态)
│ ├── tools/ # get-time.ts / get-weather.ts(两处重复收敛于此)
│ ├── memory.ts # LibSQL memory 工厂(目录保障 + 进程重启保留)
│ ├── mock/ # mock 剧本(无 key 演示,能力原样保留)
│ ├── routes/ # conversations / providers(运行时注册模型)
│ └── mastra/
│ └── index.ts # ★静态导出:Studio / mastra build 的入口
text
memory 为什么是 LibSQL
memory.ts 这个文件最初并不存在——它是被一个具体症状逼出来的:Studio 里聊得挺好,重启全没了。Studio 的会话历史默认没有本地持久化,threads 列表和调试信息都不落库。解法是给 agent 挂上 Memory,存储选了 @mastra/libsql,理由三条:
- 本地文件与云端同一套 API:
file:模式零部署(落在.temp/dev-server.db),将来要上 Turso 云端只改一行 url; - 一个库两种能力:既是 Memory 的 storage,又内建向量存储(LibSQLVector)——为 17-04 的文档语义检索留了同一底座;
- 轻量:libSQL 是 SQLite 的 fork,无独立服务进程。
挂上之后的直接效果:Studio 左侧 threads 出现历史会话记录,服务端每响应一次就多一条,点开即加载,Studio 重启多少次都在——这就是"集成本地数据库"的全部动机,没有更多玄学。
记忆体系的完整设计(网关线与 Studio 线同库防 split-brain、composite 双域存储、token 三档控制)是 17-03 的主题,这里只留架构视角的一句:memory 是 agent 定义上的可选工厂字段,不传就没有记忆——可插拔从第一天就是设计的一部分。
双入口分离:这是整个重组里最重要的一条约束
src/index.ts(服务入口)不 import src/mastra/index.ts(Studio 入口),反过来也一样:
- 服务入口 env 门控:
MASTRA_MODEL存在才把 agents 传给网关,缺省agents: []——纯 mock 模式启动路径零 Mastra 依赖,没配 key 的环境行为与重构前完全一致(这是回归验收项); - Studio 入口模块加载即构建全部 agent,缺
MASTRA_MODEL抛可读错误——反正 Studio 本来就需要真实模型,这与"纯 mock 可跑"互不干扰,靠的就是两个入口的 import 隔离。
// src/mastra/index.ts —— 只被 mastra CLI 加载
const env = readDevServerEnv()
if (!env.mastra) throw new Error('缺少环境变量 MASTRA_MODEL:…')
const def = chatAgentDefinition(env.mastra)
const chatAgent = new Agent({ /* 从 def 收窄组装 */ })
export const mastra = new Mastra({
agents: { 'chat-agent': chatAgent },
...(env.telemetry ? { telemetry: { enabled: true } } : {}),
})
typescript
env 统一:两套前缀合一套
重构前 mock-server 用 MASTRA_MODEL/MASTRA_MODEL_URL,mastra-app 用 MASTRA_APP_MODEL/MASTRA_APP_MODEL_URL/MASTRA_APP_MODEL_API_KEY…。统一后一张表(也是 .env.example):
| 变量 | 作用 |
|---|---|
MASTRA_MODEL | 模型路由串(如 deepseek/deepseek-chat),存在即启用 agents |
MASTRA_MODEL_URL | 自定义 OpenAI 兼容端点(可选) |
MASTRA_MODEL_API_KEY | 自定义端点的 key(url 场景必传——Mastra 的 url 模式不自动读 provider env) |
MASTRA_MODEL_NAME | 展示名(可选) |
MASTRA_TOKEN | 网关 Bearer(可选,/health 保持公开) |
MASTRA_TELEMETRY | Studio 遥测开关 |
DEV_SERVER_PORT | 服务端口,默认 8787 |
前端收敛
playground 的三选项后端选择器(local / mock-server / Mastra 4111)收敛为两选项(local / dev-server);mastra-adapter.ts 与 mastra-runtime-model.ts 整个删除——前端只剩一条 StreamChunk 线协议。老用户的 localStorage 里存着旧值怎么办? 读取时做别名迁移:'mock-server' 和 'mastra' 都映射到 'dev-server',未知值回退 local。改配置结构时惦记存量数据,这是组件库作者的职业习惯。
重构怎么交给 AI 做
回看整个过程,两次重构(目录整理、Mastra 深度集成)交给 AI 的指令都是自然语言,且都高度绑定业务现状,没有什么提示词技巧。能总结出三条要素:
- 把现状问题说清楚:测试混放、agent 定义两处重复、前端两条协议线——问题描述得越具体,AI 的方案越贴;
- 把目标形态说清楚:要求 Mastra 应用成为标准 Mastra App(静态导出)、深度集成进 dev-server、调整整体结构;
- 把验收标准说清楚:应用启动后必须能看到 Studio 调试面板——没有可验证的完成标准,AI 的"完成"不可判定。
如果自己折腾时 AI 始终搞不定 Mastra 集成,有一个兜底阶梯,逐级降智也逐级可靠:
- 第一级:把仓库 README 里的包依赖关系 mermaid 图源码发给 AI,要求按这个结构组织代码——图里每个包的上下游就是依赖契约;
- 第二级:把课程代码整个目录丢给 AI,让它照着抄——抄会总比猜会容易。
最终目标只有一个:mastra studio 起来后能看见应用里注册的全部工具、agent、workflow。达到了,这节的事就完了。
怎么证明重构没错:验收清单
重构最大的风险是"改了一堆,坏了不知道"。这次验收分三层:
回归层(不许变坏的):无 env 纯 mock 模式三剧本对话如常;conversations / providers 路由如常;全量 738 条测试绿;pnpm type-check 与 lint 零新增问题。
功能层(本次要变好的):
- 配
MASTRA_MODEL启动,/api/models列出chat-agent。实测对话问"北京现在几点、天气怎么样",SSE 流里依次出现:两个tool_call(getTimeTool+getWeatherTool,参数流式双帧合并正确)→ 两个tool_result(真实结果:22:44:30、19°C Smoky haze)→ 文本流 →done。统一网关的完整线协议闭环。 MASTRA_TOKEN设置后:无 Bearer 请求 401、带 token 200、/health公开。- dev-server 包内
mastra studio:Studio UI 可达,agent 列表里chat-agent的指令、双工具 schema 全部可见——删了 mastra-app,Studio 一点没少。
架构层(面向未来的):本节重构最重要的验收是这一条——
新增一个 agent = 写一个定义文件 + 注册表数组加一行。
实测过:临时写一个 20 行的 echo-agent.ts 定义(无工具、独立指令),buildAgentDefinitions 数组加一行,重启后 /api/models 立刻出现 echo-agent 并可对话。验证完删掉,机制留下。这个"配置化注册"的地基,是下一节深入设计单个 agent、再下一节扩展到通用 agent 架构的前提。
工程启示
回头看这次重构,有四条可以带走的方法论:
- "能跑"到"结构对"之间隔着一次主动重构。 功能驱动会自然长出重复和并行线(15章教学顺序的产物),不会自然消失。重构时机:当你准备在新方向上叠加功能(16-02 要加新 agent),而现有结构让"加"变别扭时。
- 机制与实例分离是组件库项目的一贯原则。 第10章 ChatAdapter 如此(组件不含后端逻辑),这次的 server/mastra 子路径也如此(库不含业务 agent)。
- 退役前分清能力与形态。 Studio 能力保留、4111 实现形态删除,靠的是搞清"CLI 只认静态导出"这个契约。
- 约束写进测试。 "双入口互不 import"不是口头约定——纯 mock 模式冒烟用例(无 env 起服务、断言模型列表只有三个 mock 项)把它固化成了回归测试。
小结
- 架构全景:8 包分两层——发布库(core/vue/markdown/agents/server)装机制,私有包(dev-server/playground/docs)装实例;原则"机制进库,实例进服务";一条消息的旅程 PromptInput → useChat → ChatAdapter → /api/chat → ModelRegistry → 模型,重构改变的是各环节归属,不改变数据流
- 前置清理:目录结构重构(测试进
__tests__镜像 src、≥2 文件才成组归目录),约定固化成.claude/rules/project-structure.md——重构产出的是"让后续开发变便宜的规则" - 三个问题:Mastra 游离 server 体系外、agent 定义两处重复漂移、前端两条协议线
- 三个决策:server 加
/mastra可选子路径(主入口零依赖增长)、mock-server 改名 dev-server 并收敛定义、删除 mastra-app 但 Studio 随静态导出保留——Studio 让 AI 行为从"盲猜"变"可观测"(pnpm studio=mastra dev,4111) - 关键约束:双入口分离(服务 env 门控 vs Studio 静态导出互不 import);env 统一一套
MASTRA_*;memory 选 LibSQL(本地/云端同 API + 内建向量存储,17-03 系统展开) - 验收:回归 738 测试全绿 + 功能实测(真实对话双工具调用流式闭环、Bearer、Studio 可达)+ 架构验收(新 agent = 一文件一行注册)
- 方法论:主动重构的时机判断 / 能力与形态分离 / 约束固化成测试 / 交给 AI 的指令三要素(现状问题 + 目标形态 + 可验证的完成标准),搞不定时 README 依赖图 → 课程代码照抄的兜底阶梯
地基整平了。下一节在这个"一个文件一行注册"的地基上,认真设计一个有业务价值的 agent——组件库 AI 助手,把提示词设计这件事真正讲透。
↑